Skip to content

js_parser: delete lexer::range_of_identifier, call Source::range_of_identifier - #48100

Open
robobun wants to merge 1 commit into
mainfrom
robobun/ac1fee24/one-range-of-identifier
Open

robobun wants to merge 1 commit into
mainfrom
robobun/ac1fee24/one-range-of-identifier

Conversation

@robobun

@robobun robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The scan that finds the end of an identifier for a diagnostic exists twice. Resolve the audited TODO(port)/TODO(b2)/PORT NOTE backlog #31783 fixed src/ast/lib.rs:1093, not the copy at src/js_parser/lexer.rs:3351, which 45 of 49 call sites use.
  • const x at the end of a file reports position.length 0. break \u{79} covers \u. export { \u{69}f } reports Expected identifier but found "\u".
  • No user reported this. A comparison with esbuild found it.

Fix

Background

  • The AST stores only the start offset of a name. A diagnostic scans the source again from there. JS reads the result as error.position.length.
  • Considered a forwarder in lexer.rs and a port of the fixes. The forwarder keeps a second public name. The port keeps two bodies.

Downsides

  • A scan of an n-byte name costs 3 + n more instructions (eval: 131 to 138, gdb, release builds).
  • A name that starts with \u{...} is scanned to its closing brace. A 1,000-byte escape costs 12,086 instructions, up from 104.
  • Three message texts change. Other identifier diagnostics stay wrong for other reasons (see Notes).
Notes

The three steps the copy had wrong

Input Diagnostic Covered on main Covered now
const x (end of file) The constant "x" must be initialized nothing x
for (;;) break abc (end of file) There is no containing label named "abc" ab abc
for (;;) { break \u{79}; } There is no containing label named "y" \u \u{79}
for (;;) { break a\u{62}; } There is no containing label named "ab" a\u{62}; a\u{62}
export { a\u{62} as x }; "ab" is not declared in this file a\u{62} as a\u{62}
for (;;) { break a\u{62}é; } There is no containing label named "abé" 8 of 9 bytes, ends inside é a\u{62}é
  • End of file: the copy stored the offset of the last codepoint, not the offset after it. With a newline or a ; after the name, main is right.
  • Leading escape: the copy read the first codepoint before its loop and did not step over a \u{...} there. The loop then stopped at {.
  • Width after the closing brace: the copy stepped over the escape by hand and kept the width of the backslash. The next decode skipped one byte.
  • The copy also panicked for loc.start < -1. The surviving body returns Range::NONE.
  • The Zig original in bun-v1.0.0 (src/js_lexer.zig:3104) has the same three steps. When Resolve the audited TODO(port)/TODO(b2)/PORT NOTE backlog #31783 fixed the other copy, 41 call sites called this one. Now 45 do.

What a user sees

  • The offset of a diagnostic never changes. Only the length does.
  • Three readers use the length:
    • error.position.length of a BuildMessage (Bun.Transpiler, Bun.build logs, a module that fails to load)
    • the error overlay of the dev server (src/runtime/bake/client/overlay.ts:725)
    • the AOT span (src/aot/lower/transpiled.rs:105)
  • The terminal prints one ^ at the start of the range and does not read the length. Terminal output changes only in three message texts:
    • export { \u{69}f };: Expected identifier but found "\u" is now Expected identifier but found "\u{69}f".
    • export { x, typeof at the end of a file: Expected identifier but found "typeo" is now "typeof".
    • export default interface at the end of a .js file: Unexpected "interfac" is now Unexpected "interface".
  • No existing test asserted the old texts.

esbuild

RangeOfIdentifier in esbuild (internal/js_lexer/js_lexer.go:631) gives the same ranges as the surviving body for the two escape steps. For an identifier that ends the file it returns length 0, because it falls through to RangeOfString. Bun covers the identifier. The unit test identifier_at_eof_includes_last_codepoint from #31783 pins that.

How to replay the edit

  1. In the 10 files that git grep -l 'lexer::range_of_identifier\|lex::range_of_identifier' -- src lists, replace X::range_of_identifier(SRC, LOC) with SRC.range_of_identifier(LOC). Two calls span lines (p.rs:3659, parse/parse_property.rs:153). One call in lexer.rs:1875 has no path prefix.
  2. Write the literal at parse/parse_import_export.rs:21 by hand. parse_import_expr has two callers. Both pass the loc of a T::TImport token. An escaped keyword lexes as T::TEscapedKeyword, so that token is always the bytes import.
  3. Delete lexer.rs:3351-3409. Remove the five imports that the compiler then names (LinkerContext.rs, parse_import_export.rs, scan_symbols.rs, visit/mod.rs, package_json.rs). Run cargo fmt --all.
  4. A script compared the calls before and after. 45 free-function calls became 44 method calls with the same source and loc arguments, token for token. The 45th is the literal.

Tests

  • 42 parser rows and 6 linker rows are new. A release build of main 7ca0042 fails 36 and 6. The 6 parser rows that pass are the group "in a spelling that was already right".
  • The linker rows are in bun-build-api.test.ts because only the JS API exposes a length.
  • Each fixed step is pinned. I removed one step at a time from src/ast/lib.rs. That run used an earlier set of 39 parser rows and 4 linker rows. Without the leading-escape skip, 20 and 2 fail. Without cursor.width = 0, 18 and 2. Without the end-of-file width, 7 and 0.
  • bun bd test test/bundler/transpiler/transpiler.test.js: 300 pass, 0 fail.
  • bun bd test test/bundler/bun-build-api.test.ts: 96 pass and 1 timeout. The timeout is the existing stress test Bun.build can be called thousands of times in one process without crashing. It needs 400 builds in 180 s, and the ASAN debug build here takes about 1 s for each. The release builds of main and of this branch both run 40 of those builds in 1.3 s.
  • Also run on the debug build, all pass: bundler_edgecase.test.ts, esbuild/importstar.test.ts, parse-error-column.test.ts, resolve-error.test.ts, assign-to-import.test.ts, decorators.test.ts, scope-mismatch-panic.test.ts, and the regression tests 23649 and 012039.

Measurements (release builds of main 7ca0042 and of this branch, --profile=release)

  • Instructions per scan (gdb single-step from entry to return), with one conditional branch fewer in each:
    • function eval(){}: 131 to 138
    • var arguments = 1: 226 to 238
    • require(x): 188 to 198
    • import(x): 169 to 178
  • import.meta: 1 scan of 169 instructions to 0 scans. A file of 1,000 import.meta statements hits the scan 1,000 times on main and 0 times now.
  • .text: 110,627,497 to 110,625,705 bytes (llvm-size). bun_js_parser::lexer::range_of_identifier (1,737 bytes) is gone. <bun_ast::Source>::range_of_identifier is 2,042 bytes in both.
  • Calls out of the surviving body (objdump): no allocator call. The targets are panic_bounds_check, unwrap_failed, and memcpy on the non-ASCII path.
  • A name that starts with a 1,000-byte escape: 104 to 12,086 instructions per diagnostic. The copy stopped after 2 bytes. With the escape after the first character: 6,081 to 8,091.
  • Worst case tried: 20,000 diagnostics whose note points at one name that starts with a 400,000-byte escape. The median goes from 5.07 s to 8.15 s (10 interleaved runs each, spread 0.7 s).
  • A matrix of 819 spellings and 5 terminators: wrong ranges at a label diagnostic 1,771 to 0 of 4,095. At a redeclaration, which already used the surviving body, 0 to 0.

Still wrong after this PR (same output on main and on this branch, lines as on main)

  • Five private-name ranges take the length from the decoded name, as esbuild does (visit/visit_expr.rs:994, :1011, :1027, :1043, visit/visit_binary.rs:743). this.#\u{62} covers #\. In the same matrix 2,805 of 4,095 rows are wrong.
  • parse/parse_prefix.rs:400 also starts at the object. delete this.#bcdef covers this.#.
  • A name that is a string literal gets length 0. Examples are export { a as "x", b as "x" } and a second "__proto__" key. export { "a b" } reports Expected identifier but found "". esbuild falls back to RangeOfString.
  • continue to a label that is not a loop underlines continue, not the label (visit/visit_stmt.rs:1403).
  • A setter with no parameter underlines nothing (parse/parse_property.rs:153).
  • TypeScript import { type as eval } reports Cannot use eval as an identifier here with length 0 (parse/parse_import_export.rs:187).
  • import { x as eval } underlines x, not eval (parse/parse_import_export.rs:230).
  • The await error underlines the token after await (lexer.rs:1884, Irrelevant error: using await in non-async function #13097).
  • hoist_symbols writes its own redeclaration error, with no quotes and with the error on the first declaration (p.rs:3643).
  • export default 1; export default function foo() {} underlines foo (parse/parse_stmt.rs:1158).
  • TypeScript import abc = require('x') stores no loc. A note about abc sits at offset 0 (parse/parse_stmt.rs:1635).

Open PRs that call the deleted name


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts

…dentifier

The scan that finds the end of an identifier for a diagnostic existed
twice. #31783 fixed three steps in `bun_ast::range_of_identifier`: a
name that starts with a `\u{...}` escape, the byte after the closing
brace of an escape, and a name that ends the file. The copy in
lexer.rs did not get those fixes, and 45 call sites still called it.

Delete the copy. 44 call sites in the parser, the resolver and the
linker call `Source::range_of_identifier` with the same arguments. The
`import.meta` site stores the length of the `import` keyword and does
not scan.
@github-actions github-actions Bot added the claude label Nov 7, 2026
@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:59 AM PT - Nov 7th, 2026

❌ @robobun, your commit 45b5078 has 76 failures in Build #138268 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48100

That installs a local version of the PR into your bun-48100 executable, so you can run:

bun-48100 --bun

@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

@coderabbitai

coderabbitai Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

Parser, linker, and resolver diagnostic locations now use Source::range_of_identifier instead of the lexer helper. The helper was removed. New tests check diagnostic spans for escaped and unescaped identifiers.

Changes

Identifier diagnostic ranges

Layer / File(s) Summary
Parser diagnostic range migration
src/js_parser/lexer.rs, src/js_parser/p.rs, src/js_parser/parse/*, src/js_parser/scan/*, src/js_parser/visit/*
Parser diagnostic call sites now obtain identifier ranges through Source::range_of_identifier. The free lexer helper was removed.
Linker and resolver diagnostic ranges
src/bundler/LinkerContext.rs, src/resolver/package_json.rs
Linker and package.json diagnostics now obtain identifier ranges through Source::range_of_identifier.
Diagnostic span tests
test/bundler/bun-build-api.test.ts, test/bundler/transpiler/transpiler.test.js
Added tests check complete source spans for escaped and unescaped identifier spellings in linker and transpiler diagnostics.

Suggested reviewers: jarred-sumner

Merge Risk: Minimal · up to 45b50

No concrete merge-blocking issue is established. The diagnostic-range migration is mergeable subject to normal build and test checks.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title clearly identifies the main change: replacing the lexer helper with Source::range_of_identifier.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description explains the changes and how the author verified them. It also reports the test timeout and remaining limitations.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 7ca0042 and 45b5078.

Files selected for processing: 13

  • src/bundler/LinkerContext.rs
  • src/js_parser/lexer.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_import_export.rs
  • src/js_parser/parse/parse_property.rs
  • src/js_parser/parse/parse_stmt.rs
  • src/js_parser/scan/scan_symbols.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser/visit/visit_stmt.rs
  • src/resolver/package_json.rs
  • test/bundler/bun-build-api.test.ts
  • test/bundler/transpiler/transpiler.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — a mechanical dedup that routes every identifier-range diagnostic through the already-fixed Source::range_of_identifier, with tests that pin each corrected case.

What was reviewed:

  • All 44 swapped call sites pass the same source/loc as before; no caller of the deleted lexer copy remains, and the leftover js_lexer/CodepointIterator imports are still live.
  • The hard-coded len: 6 for import.meta: both callers of parse_import_expr pass the loc of a T::TImport token, and an escaped import lexes as T::TEscapedKeyword, so the token is always the 6 bytes import.
  • The surviving body in src/ast/lib.rs is unchanged here, already had four callers and unit tests; the leading-\u{ scan is bounded by source length and only runs on diagnostic paths.
  • New tests assert exact message plus covered-text pairs via toEqual, and no existing test asserted the three old truncated message strings.

Optional nit: the doc comment above range_of_identifier in src/ast/lib.rs:1089 ("Was bun_js_parser::lexer::rangeOfIdentifier. Moved into logger to break logger→js_parser") now describes a copy that no longer exists; worth trimming while you're here.

Extended reasoning...

The PR deletes the duplicate range_of_identifier in src/js_parser/lexer.rs and rewrites 44 call sites across the parser, linker and resolver to call the pre-existing Source::range_of_identifier method, plus one site that replaces a scan of the import keyword with a fixed 6-byte range. It touches no security-sensitive surface; the only effect is the length of diagnostic ranges (error.position.length) and three message texts that were previously truncated. Approval was decided by the fact that every swap is argument-identical, the single semantic change was verified against both callers and the lexer's escaped-keyword handling, the surviving helper is untouched and already tested, and the added test.each suites assert exact values and cover leading/trailing escapes, EOF and non-ASCII. No CODEOWNERS entry applies and the timeline shows no outstanding reviewer objections.

@robobun

robobun commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. I left the doc comment at src/ast/lib.rs:1089 as it is on main:

  • The note names the Zig function rangeOfIdentifier, not the Rust copy that this PR deletes. It records why the scan lives in bun_ast and not in the lexer: bun_ast cannot depend on bun_js_parser. That is still true.
  • This PR keeps src/ast/lib.rs identical to main, so the surviving scan and its unit tests are untouched.
  • A Loc is always a byte offset: Option<Loc> for absence, no -1 sentinel #38825 edits the same doc comment and the signature below it. A trial git merge-file of src/ast/lib.rs against its head gives 13 conflict hunks today and 14 with the comment trimmed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant